신입 개발자의 TDD
김승환안녕하세요. 팀스파르타 신입 개발자 김승환입니다. 저는 이번에 팀스파르타에 들어와서 온보딩 과정으로 TDD 방식으로 간단한 결제 모듈을 만들어보는 프로젝트를 진행했습니다. 덕분에 TDD가 어떻게 진행되는지 많은 것을 배울 수 있었습니다. 그래서 이번 글에서는 TDD에 대해 제가 어떤 것을 배웠는지 공유해보도록 하겠습니다.
Test-Driven Delvelopment(TDD)
간단하게 TDD에 대해 설명을 해드리면, 테스트 주도 개발 (Test-Driven Development, TDD)란 기존에 설계 → 개발 → 테스트 순으로 진행 되던 방식에서 벗어나 설계 → 테스트 → 개발 순으로 개발을 진행하는 방식입니다.
TDD 방식의 장점은 다음과 같은 장점이 있습니다.
- 설계 수정 시간을 단축 할 수 있다.
- 디버깅 시간도 줄고 유지 보수도 훨씬 수월해진다.
- 테스트 문서를 대체할 수있다.
그럼 각 단계에 대해 설명해보도록 하겠습니다.
1. 설계
기존의 방식과 TDD의 설계는 차이점이 없습니다. 설계 단계에서는 소프트웨어 시스템이 원하는 기능과 품질을 가질 수 있도록 설계해야하며, 시스템을 용이하게 구축할 수 있고, 지속적인 사용과 개선을 위하여 필요한 구조를 가지도록 해야합니다. 사실 저도 아직은 이 분야를 잘 몰라서 이 정도만 하고 넘어가도록 하겠습니다.
2. 테스트
TDD에서 테스트 코드는 실제 코드 보다 먼저 작성해야 합니다. 저는 이것이 가능한 이유가 다음과 같은 특징을 가지고 있기 때문이라고 생각합니다.
eXtream Programming(XP)
미래에 대한 예측을 최대한 하지 않고,
지속적으로 프로토타입을 완성하는 애자일 방법론 중 하나이다.
이 방법론은 추가 요구사항이 생기더라도, 실시간으로 반영할 수 있다.단위 테스트(Unit Test)
말 그대로 한 단위(일반적으로 class)만을 테스트하는 것이다.기존의 개발 과정은 시스템에 필요한 코드를 다 짠 후에 테스트를 진행하는 방식이지만, TDD 방식은 작은 단위로 하나씩 진행하는 방식입니다. 그렇기 때문에 위의 개발 순서 설계 → 테스트 → 개발은 정확히 말하면 설계 → 테스트 → 개발 → 테스트 → 개발 → 테스트 → 개발 →…가 될 것입니다. 또한 테스트 코드는 테스트 시나리오를 바탕으로 제작하게 될 텐데, 테스트 시나리오 또한 코드를 짜면서 여러번 수정하게 될 것입니다.
기본적으로 테스트 코드는 주어진 상황에서 일련의 과정을 거쳐서 나온 결과물이 예상한 결과물과 비교하여 맞는지를 확인하게 됩니다. 그리고 개발 단계 이전에 테스트 코드를 작성하기 때문에 처음 작성한 테스트 코드의 결과는 당연히 실패입니다. 좀 더 자세한 내용은 후에 코드와 함께 이야기하도록 하겠습니다.
3. 개발

개발 단계에서 주로하는 것은 위에서 만든 테스트 케이스를 통과하는 코드를 작성하는 것과 그 코드를 리팩토링하는 것입니다.
통과하는 코드를 작성하는 것도 몇 가지 원칙이 있지만, 켄트 벡(Test-Driven Development: By example 저자)에 의하면 우선 어떻게 해서든 통과시키는 것이 가장 중요하다고 합니다.
더하기 함수 코드 작성을 TDD 방식을 따라 진행해보겠습니다. Jest를 활용한 테스트 코드는 다음과 같습니다.
test("더하기 테스트", ()=>{
const result = add(3, 5);
expect(result).toEqual(8);
});더하기 함수에 3과 5를 넣으면 결과는 8이 나올 것이라고 예측한 테스트 코드입니다. 이 코드는 컴파일 단계부터 실패하게 되는데, add 함수 자체가 없기 때문입니다. 그러므로 add 함수를 추가해 주어야 합니다.
const add = (a, b) => {
return 0;
}import와 export 같은 디테일은 넘기기로 하고, add 함수를 만들었으므로 컴파일 단계에서 오류는 더 이상 발생하지 않습니다. 다만, 결과물 0이 예측값 8과 다르기 때문에 테스트는 실패하게 됩니다. 우선 이 테스트를 통과시키기 위해서 return 값을 빠르게 바꿔 보겠습니다.
const add = (a, b) => {
return 8;
}위와 같이 작성하면 컴파일도 문제가 없고 테스트도 통과할 것입니다. 하지만 이는 누가봐도 문제가 많은 것 같은 코드입니다. 즉, 리팩토링이 필요하고 리팩토링에서 주로 하는 것은 상수를 변수로, 그리고 중복 제거입니다. 물론 리팩토링 과정에서 이 외에도 다양한 문제가 발생할 것이고 그때 그때마다 그 문제들을 해결해야 할 것입니다. add 함수의 return 값이 상수이므로 이를 변경시켜보겠습니다.
const add = (a, b) => {
return a + b;
}마침내 테스트 케이스를 통과하는 더하기 함수를 만들었습니다. 지금은 더하기 함수를 만드는 것이 목적이였기에 여기서 끝나지만, 사칙연산 시스템을 만들기 위해 빼기, 곱하기, 나누기 함수를 만든다면 하나씩 위와 같은 방식으로 진행하면 됩니다. 또한 위의 시나리오에서는 두 개의 숫자만 더하고 있지만, 좀 더 일반적으로 세 개의 수 혹은 그 이상을 더해주는 함수를 제작하는 시나리오를 추가할 수도 있을 것 같습니다.
추가로 생각해볼 사항
다음으로는 테스트를 작성하면서 생각해볼만한 사항들입니다.
테스트끼리는 독립적이여야 한다.
각 테스트끼리는 서로 아무런 영향을 끼치지 말아야 합니다. 즉, 서로 격리되고 독립적이어야한다는 의미입니다. 그러므로, 테스트끼리 순서가 바뀌더라도 테스트 통과에는 아무런 문제가 없어야 합니다. 예시와 함께 살펴보겠습니다.
class Order{
constructor(productName, price, num) {
this.productName = productName;
this.price = price;
this.num = num;
}
}
function totalPrice(order){
return order.num * order.price;
}테스트를 위해 간단한 객체와 함수를 작성해 보았습니다. 상품의 이름, 가격, 주문 수량을 담고 있는 Order 클래스와 order 객체를 보냈을 때 총 가격을 반환하는 함수 입니다.
describe("물건 구매", () => {
const order = new Order("pencil", 1000, 5);
test("주문 수량을 변경할 수 있다.", () => {
order.num = 10;
expect(order.num).toEqual(10);
})
test("주문의 총 가격을 얻을 수 있다.", () => {
expect(totalPrice(order)).toEqual(???);
})
})주문 정보의 수량을 변경하는 테스트와 총 가격을 확인하는 테스트를 진행해보겠습니다. 위의 두 번째 테스트에는 어떤 값이 들어가야 할까요? 첫 번째 테스트에서 주문을 10개로 변경했기 때문에 통과하려면 10000이 되야할 것입니다. 그러나 이는 몇 가지 문제점이 있습니다. 테스트를 진행하다보면 어떤 테스트는 실행하거나 스킵할 수도 있습니다. 만약, 첫 번째 테스트를 스킵하면 두 번째 테스트는 실패하게 될 것입니다. 그리고 모종의 이유로 두 번째 테스트가 실패하게 되고, 다른 사람이 해결을 위해 두 번째 테스트를 확인하게 되는 경우 두 테스트의 의존적인 관계를 파악하지 못 할 경우 또 다른 문제가 생길 수 있습니다. 이러한 의존적인 관계를 제거하는 테스트로 변경해 보겠습니다.
describe("물건 구매", () => {
let order;
beforeEach(()=> {
order = new Order("pencil", 1000, 5);
})
test("주문 수량을 변경할 수 있다.", () => {
order.num = 10;
expect(order.num).toEqual(10);
})
test("주문의 총 합을 얻을 수 있다.", () => {
expect(totalPrice(order)).toEqual(5000)
})
})테스트가 실행 되기전에 객체를 새롭게 할당해 줌으로써 서로 독립적인 테스트가 된 것처럼 보입니다. 그리고 위의 언급했던 문제들도 더 이상 발생하지 않습니다. 이처럼 각 테스트가 서로 영향을 주지 않도록 주의하며 작성해야 합니다.